Linux中國

這兒幾個位元組,那裡幾個位元組,我們說的是真正的內存

今天的帖子來自於最近的 Go 語言的一次小測試,觀察下面的測試基礎片段 [1]

func BenchmarkSortStrings(b *testing.B) {
        s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
                sort.Strings(s)
        }
}

sort.Stringssort.StringSlice(s) 的便捷包裝器,sort.Strings 在原地對輸入進行排序,因此不會分配內存(或至少 43% 回答此問題的 Twitter 用戶是這麼認為的)。然而,至少在 Go 的最近版本中,基準測試的每次迭代都會導致一次堆分配。為什麼會是這種情況?

正如所有 Go 程序員應該知道的那樣,介面是以 雙詞結構 實現的。每個介面值包含一個欄位,其中保存介面內容的類型,以及指向介面內容的指針。 [2]

在 Go 語言偽代碼中,一個介面可能是這樣的:

type interface struct {
        // the ordinal number for the type of the value
        // assigned to the interface 
        type uintptr

        // (usually) a pointer to the value assigned to
        // the interface
        data uintptr
}

interface.data 可以容納一個機器字(在大多數情況下為 8 個位元組),但一個 []string 卻需要 24 個位元組:一個字用於指向切片的底層數組;一個字用於存儲切片的長度;另一個字用於存儲底層數組的剩餘容量。那麼,Go 是如何將 24 個位元組裝入個 8 個位元組的呢?通過編程中最古老的技巧,即間接引用。一個 []string,即 s,需要 24 個位元組;但 *[]string —— 即指向字元串切片的指針,只需要 8 個位元組。

逃逸到堆

為了讓示例更加明確,以下是重新編寫的基準測試,不使用 sort.Strings 輔助函數:

func BenchmarkSortStrings(b *testing.B) {
        s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
                var ss sort.StringSlice = s
                var si sort.Interface = ss // allocation
                sort.Sort(si)
        }
}

為了讓介面正常運行,編譯器將賦值重寫為 var si sort.Interface = &ss,即 ss 的地址分配給介面值。 [3] 我們現在有這麼一種情況:出現一個持有指向 ss 的指針的介面值。它指向哪裡?還有 ss 存儲在哪個內存位置?

似乎 ss 被移動到了堆上,這也同時導致了基準測試報告中的分配:

Total:    296.01MB   296.01MB (flat, cum) 99.66%
      8            .          .           func BenchmarkSortStrings(b *testing.B) { 
      9            .          .             s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"} 
     10            .          .             b.ReportAllocs() 
     11            .          .             for i := 0; i < b.N; i++ { 
     12            .          .                 var ss sort.StringSlice = s 
     13     296.01MB   296.01MB                 var si sort.Interface = ss // allocation 
     14            .          .                 sort.Sort(si) 
     15            .          .             } 
     16            .          .           } 

發生這種分配是因為編譯器當前無法確認 sssi 生存期更長。Go 編譯器開發人員對此的普遍態度是,覺得 這個問題改進的餘地,不過我們另找時間再議。事實上,ss 就是被分配到了堆上。因此,問題變成了:每次迭代會分配多少個位元組?為什麼不去詢問 testing 包呢?

% go test -bench=. sort_test.go
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHz
BenchmarkSortStrings-4          12591951                91.36 ns/op           24 B/op          1 allocs/op
PASS
ok      command-line-arguments  1.260s

可以看到,在 amd 64 平台的 Go 1.16 beta1 版本上,每次操作會分配 24 位元組。 [4] 然而,在同一平台先前的 Go 版本中,每次操作則消耗了 32 位元組。

% go1.15 test -bench=. sort_test.go
goos: darwin
goarch: amd64
BenchmarkSortStrings-4          11453016                96.4 ns/op            32 B/op          1 allocs/op
PASS
ok      command-line-arguments  1.225s

這引出了本文的主題,即 Go 1.16 版本中即將推出的一項便利改進。不過在討論這個內容之前,我需要聊聊 「 尺寸類別 size class 」。

尺寸類別

在解釋什麼是 「 尺寸類別 size class 」 之前,我們先考慮個問題,理論上的 Go 語言在運行時是如何在其堆上分配 24 位元組的。有一個簡單的方法:追蹤目前為止已分配到的所有內存的動向——利用指向堆上最後分配的位元組的指針。分配 24 位元組,堆指針就會增加 24,然後將前一個值返回給調用函數。只要寫入的請求 24 位元組的代碼不超出該標記的範圍,這種機制就沒有額外開銷。不過,現實情況下,內存分配器不僅要分配內存,有時還得釋放內存。

最終,Go 語言程序在運行時將釋放這些 24 位元組,但從運行的視角來看,它只知道它給調用者的開始地址。它不知道從該地址起始之後又分配了多少位元組。為了允許釋放內存,我們假設的 Go 語言程序運行時分配器必須記錄堆上每個分配的長度值。那麼這些長度值的分配存儲在何處?當然是在堆上。

在我們的設想中,當程序運行需要分配內存的時候,它可以請求稍微多一點,並把它用來存儲請求的數量。而對於我們的切片示例而言,當我們請求 24 位元組時,實際上會消耗 24 位元組加上存儲數字 24 的一些開銷。這些開銷有多大?事實上,實際上的最小開銷量是一個字。 [5]

用來記錄 24 位元組分配的開銷將是 8 位元組。25% 不是很大,但也不算糟糕,隨著分配的大小增加,開銷將變得微不足道。然而,如果我們只想在堆上存儲一個位元組,會發生什麼?開銷將是請求數據量的 8 倍!是否有一種更高效的方式在堆上分配少量內存?

與其在每個分配旁邊存儲長度,不如將相同大小的內容存儲在一起,這個主意如何?如果所有的 24 位元組的內容都存儲在一起,那麼運行時會自動獲取它們的大小。運行時所需要的是一個單一的位,指示 24 位元組區域是否在使用中。在 Go 語言中,這些區域被稱為 Size Classes,因為相同大小的所有內容都會存儲在一起(類似學校班級,所有學生都按同一年級分班,而不是 C++ 中的類)。當運行時需要分配少量內存時,它會使用能夠容納該分配的最小的尺寸類別。

無限制的尺寸類別

現在我們知道尺寸類別是如何工作的了,那麼問題又來了,它們存儲在哪裡?和我們想的一樣,尺寸類別的內存來自堆。為了最小化開銷,運行時會從堆上分配較大的內存塊(通常是系統頁面大小的倍數),然後將該空間用於單個大小的分配。不過,這裡存在一個問題————

將大塊區域用於存儲同一大小的事物的模式很好用 [6] ,如果分配大小的數量是固定的,最好是少數幾個。那麼在通用語言中,程序可以要求運行時以任何大小分配內存 [7]

例如,想像一下向運行時請求 9 位元組。9 位元組是一個不常見的大小,因此可能需要一個新的尺寸類別來存儲 9 位元組大小的物品。因為 9 位元組大小的物品不常見,所以分配的其餘部分(通常為 4KB 或更多)可能會被浪費。由於尺寸類別的集合是固定的,如果沒有精確匹配的 size class 可用,分配將併入到下一個尺寸類別。在我們的示例中,9 位元組可能會在 12 位元組的尺寸類別中分配。未使用的 3 位元組的開銷要比幾乎未使用的整個尺寸類別分配好。

總結一下

這是謎題的最後一塊拼圖。Go 1.15 版本沒有 24 位元組的尺寸類別,因此 ss 的堆分配是在 32 位元組的尺寸類別中分配的。由於 Martin Möhrmann 的工作,Go 1.16 版本有一個 24 位元組的尺寸類別,非常適合分配給介面的切片值。

相關文章

  1. 我在 Devfest 2017年西伯利亞大會談 Go 語言
  2. 如果對齊的內存寫操作是原子性的,為什麼我們還需要 sync/atomic 包呢?
  3. 為你的樹莓派創建一個真實的串列控制台
  4. 為什麼 Go 語言線程的棧是無限制的?

(題圖:MJ/01d5fe46-778f-48fe-9481-162f4d0289dc)

  1. 這不是正確的對排序函數進行基準測試的方式,因為在第一次迭代之後,輸入已經排序。但這又是另外一個話題了。 ↩︎
  2. 此語句的準確性取決於所使用的 Go 版本。例如,Go 1.15 版本添加了直接將一些 整數存儲在介面值 中的功能,從而節省了分配和間接性。然而,對於大多數值來說,如果它不是指針類型,它的地址將被取出並存儲在介面值中。 ↩︎
  3. 編譯器在介面值的類型欄位中跟蹤了這種手法,因此它記住了分配給 si 的類型是 sort.StringSlice 而不是 *sort.StringSlice↩︎
  4. 在 32 位平台上,這個數字減半,但我們不再關注它↩︎
  5. 如果你準備限制分配為 4G 或者可能是 64KB,你可以使用較少內存來存儲分配的尺寸,但實際上使用小於一個字來存儲長度標頭的節省會受到填充的影響。 ↩︎
  6. 將相同大小的物品存儲在一起也是一種有效的對抗碎片化的策略。 ↩︎
  7. 這並不是一個不切實際的設想,字元串有各種形狀和大小,生成以前未見過的大小的字元串可能就像附加空格一樣簡單。 ↩︎

via: https://dave.cheney.net/2021/01/05/a-few-bytes-here-a-few-there-pretty-soon-youre-talking-real-memory

作者:Dave Cheney 選題:lujun9972 譯者:Drwhooooo 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國